
上一篇結尾留了幾件事,path 參數 {id} 和 query 參數怎麼拿、怎麼驗,還有 /todos 和 /todos/ 為什麼預設是 2 條路徑、想讓它們走同一條要怎麼做,這篇全部處理,Todo API 也照著主線長大,加上取單筆待辦的端點 GET /todos/{id},GET /todos 順便支援 ?limit=N,最後有一個實驗,day 05 說過那個尾斜線測試「會第一時間告訴我們行為變了」,這篇會親眼看到這件事發生
GET /todos/{id},GET /todos 支援 ?limit=N
參數的邊界事先就想得清楚,測試一樣先寫,在 src/test/kotlin/com/cashwu/todo/TodoRoutesTest.kt 的 class 裡加上 7 個測試,import 區多一行 io.ktor.client.request.post,最後一個測試要用 client.post
@Test
fun `todo by id responds single todo`() = testApplication {
application {
module()
}
val response = client.get("/todos/1")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("買牛奶", response.bodyAsText())
}
@Test
fun `todo by unknown id responds not found`() = testApplication {
application {
module()
}
val response = client.get("/todos/999")
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun `todo by non numeric id responds bad request`() = testApplication {
application {
module()
}
val response = client.get("/todos/abc")
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun `todos with limit query responds limited todos`() = testApplication {
application {
module()
}
val response = client.get("/todos?limit=2")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("買牛奶\n繳電費", response.bodyAsText())
}
@Test
fun `todos with non numeric limit responds bad request`() = testApplication {
application {
module()
}
val response = client.get("/todos?limit=abc")
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun `todos with negative limit responds bad request`() = testApplication {
application {
module()
}
val response = client.get("/todos?limit=-1")
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun `post todos responds method not allowed`() = testApplication {
application {
module()
}
val response = client.post("/todos")
assertEquals(HttpStatusCode.MethodNotAllowed, response.status)
}
前 6 個圍繞新功能的行為和邊界,拿存在的 id 回 200 和那筆待辦、拿不存在的 id 回 404、拿非數字 id 回 400、帶 ?limit=2 只回前 2 筆。limit 不是數字或小於 0 也回 400,不讓無效輸入悄悄變成「全部拿」,更不能一路丟出 500
最後一個比較特別,打 POST /todos,期望 405。這篇從頭到尾不會寫任何 POST 的程式碼,這個測試跟 day 05 的尾斜線測試是同一種用途,把框架的預設行為用測試確認下來,path 對到了但 method 沒對到時,Ktor 回的是 405 不是 404
把 src/main/kotlin/com/cashwu/todo/Application.kt 的 routing 區塊改成下面這樣,get("/") 那條保留,day 05 原本那條 get("/todos") 換成下面這 2 條,不是留著再往後加,import 區多一行 io.ktor.http.HttpStatusCode
get("/todos") {
val limitText = call.request.queryParameters["limit"]
val limit = if (limitText == null) todos.size else limitText.toIntOrNull()?.takeIf { it >= 0 }
if (limit == null) {
call.respondText("limit 要是 0 以上的整數", status = HttpStatusCode.BadRequest)
return@get
}
call.respondText(todos.take(limit).joinToString("\n"))
}
get("/todos/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("id 要是數字", status = HttpStatusCode.BadRequest)
return@get
}
val todo = todos.getOrNull(id - 1)
if (todo == null) {
call.respondText("找不到 id $id 的待辦", status = HttpStatusCode.NotFound)
return@get
}
call.respondText(todo)
}
改動不大,但每一行背後都有一個要弄清楚的行為,逐段拆
上面特別提了一句「換掉不是往後加」,是因為 Ktor 對「同一個 path 加同一個 method 註冊 2 次」不會報錯也不會警告,比對到的時候走先註冊的那一條,後面那條一次都不會被執行
真的變成 2 條的時候,症狀是 3 個跟 limit 有關的測試一起倒,?limit=2 回的是完整清單、?limit=abc 沒有回 400,但 GET /todos/{id} 那幾個測試全過。看起來像 query 參數這段寫錯,實際上是這段程式碼根本沒跑到,第 1 個要看的地方不是 handler 裡面,是 routing 區塊裡同一條 path 出現了幾次
{id} 什麼都接get("/todos/{id}") 路徑裡的 {id} 是參數 segment,這一段不比對固定字串,那個位置出現什麼它就接什麼,/todos/1、/todos/999、/todos/abc 全部都會進到這個 handler,要注意的是,匹配只看「這個位置有一段」,內容合不合理它不管,驗證是 handler 的責任
補一句優先序的事,路由匹配時具體的 segment 比參數 segment 優先,之後如果註冊了 get("/todos/all") 這種固定路徑,/todos/all 會走它,不會被 {id} 攔走,目前 /todos 底下只有 {id} 這一條,所以 abc 也進了 handler,由裡面的驗證擋下
call.parameters["id"] 拿到的型別是 String?,不是數字,URL 的 path 本來就只是字串,框架不知道你的 id 是數字、UUID 還是別的東西,型別轉換與驗證得自己來,toIntOrNull() 轉不動就回 null,配上 early return 是最直接的寫法,return@get 的 @get 是 label,lambda 裡的 return 要指名跳出哪一層,這裡跳出的是 get(...) 的 handler,後面的程式碼不會再跑
id 不是數字,跟 id 是數字但查不到,是 2 件事,前者是請求本身格式有問題,回 400 Bad Request,後者是請求格式沒問題,只是資源不存在,回 404 Not Found,混在一起回同一個狀態碼,client 就分不出「我打錯了」和「東西不在」,所以實作裡是 2 段獨立的檢查、2 個不同的狀態碼,測試也各測各的
todos.getOrNull(id - 1) 把 id 當成清單的第幾筆,從 1 開始算,這是刻意的簡化,跟 day 05 把資料放在 mutableList 是一樣的,目前的資料就是一個記憶體裡的清單,沒有真正的 id 欄位,day 20 之後換上資料庫,id 才會是資料庫給的,getOrNull 超出範圍回 null,正好接上 404 那段檢查
query 參數從 call.request.queryParameters 拿,跟路徑參數一樣拿到 String?,沒帶這個參數就是 null,沒有 limit 時取完整清單,有值就用 toIntOrNull() 轉型,再用 takeIf { it >= 0 } 擋掉負數,任何一步失敗都回 400,避免 ?limit=abc 被當成「全部拿」,也避免 take(-1) 丟出例外變成 500
這裡先用直接的 if 寫清楚單一欄位的規則,後面會把輸入驗證集中到 RequestValidation,重點是讓規則離開 handler,不是等到那時才開始拒絕無效輸入
./gradlew test
實測的結果是
> Task :test
ApplicationTest > root path responds hello() PASSED
ApplicationTest > unknown path responds not found() PASSED
...
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
12 個測試通過,7 個是這篇新增的,day 05 那個尾斜線測試也還在名單裡,等一下實驗時它會是主角,接著 ./gradlew run 跑起 server,另開視窗逐一用 curl 確認。先取單筆
curl http://localhost:8080/todos/1
買牛奶
打一個不存在的 id,用 -w 順便印出狀態碼
curl -w "\n%{http_code}" http://localhost:8080/todos/999
找不到 id 999 的待辦
404
換非數字
curl -w "\n%{http_code}" http://localhost:8080/todos/abc
id 要是數字
400
query 參數,URL 要用引號包起來,? 在 shell 裡有特殊意義
curl "http://localhost:8080/todos?limit=2"
買牛奶
繳電費
無效的 limit 直接回 400
curl "http://localhost:8080/todos?limit=-1" -w "\n%{http_code}"
limit 要是 0 以上的整數
400
最後對 /todos 發一個 POST
curl -i -X POST http://localhost:8080/todos
回應的前 2 行是
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
我們沒寫半行 POST 相關的程式碼,這個 405 是 Ktor 的預設行為,路由匹配時 path 和 method 是分開看的,/todos 這個 path 有註冊,只是沒有 POST 的 handler,這種「path 對到了但 method 沒對到」的情況回 405 Method Not Allowed,而不是跟「path 根本不存在」一樣回 404,這個區分是有語意的,404 告訴 client 資源不存在,405 告訴 client 資源在、只是動詞用錯了
day 05 留了一個測試,打 /todos/ 期望 404,當時說「之後 day 06 動到它時,這個測試會第一時間告訴我們行為變了」,現在就來動它
Ktor 預設把 /todos 和 /todos/ 當成 2 條路徑,想讓它們一視同仁,官方給的開關是 IgnoreTrailingSlash 這個 plugin,在 Application.kt 的 module() 裡、routing 區塊前面加一行
install(IgnoreTrailingSlash)
要搭配 2 行 import,io.ktor.server.application.install 和 io.ktor.server.routing.IgnoreTrailingSlash
它跟路由的匹配規則綁在一起,所以住在 io.ktor.server.routing 底下,遇到 unresolved reference 先檢查 import 是不是猜錯了 package
裝好之後跑 ./gradlew test,day 05 那個測試立刻失敗
TodoRoutesTest > todos path with trailing slash responds not found() FAILED
org.opentest4j.AssertionFailedError: expected: <404 Not Found> but was: <200 OK>
/todos/ 從 404 變成 200,裝了這個 plugin,/todos/ 和 /todos 就一視同仁了,這正是 day 05 說的那件事,用測試把預設行為固定下來,行為一變,測試第一時間告訴你,它失敗不代表程式壞了,代表有一條規格被改動了,接下來要做的是決定,這個改動是你要的就改測試,不是就把改動拿掉
實驗做完,把 install(IgnoreTrailingSlash) 和那 2 行 import 拿掉,維持 Ktor 預設的嚴格行為,再跑一次 ./gradlew test 確認 12 個測試回到全部通過,理由很單純,API 對外的路徑行為明確一點比較好,/todos 就是 /todos
這是取捨,不是唯一正解,等一下會看到 Relix 選了另一邊,重點是知道有這個開關,也知道測試會保護你做這種決定,哪天真要開,加一行、改一個測試就好,影響範圍全在掌握裡
這篇碰到的 3 件事,405、trailing slash、路徑參數,Relix 都手刻過,對照起來每一個都能看出框架的預設替你做了什麼決定
normalizePath 把尾斜線統一處理掉,/hello 和 /hello/ 走同一條,選的是寬鬆策略,Ktor 預設嚴格,要一視同仁得自己裝 IgnoreTrailingSlash,day 05 對照段講過「框架的預設跟你的直覺不一定同一邊」,這句話更具體了,2 個框架對同一件事的預設可以完全相反,所以才要用測試把你依賴的那個預設固定住call.parameters["id"] 拿到的就是同一件事的成品,segment 比對、參數提取、優先序,路由樹裡全做完了,不過有一件事兩邊一模一樣,參數拿到手永遠是 String,驗證是使用者的責任,這條線框架不會替你跨過去,因為只有你知道 id 該長什麼樣Todo API 現在用 200、400、404 分清楚正常結果、格式錯誤與找不到資料,非法 limit 也不會再被當成完整清單或變成 500,路由層另外固定了 405 與尾斜線的嚴格預設,之後重構或換路由寫法,測試會直接指出哪些行為被改動
routing 區塊現在有 3 條路由,全部擠在 module() 裡,之後每篇都會再往上加,加到後面這個函式會變成一長串,想找某條路由得從頭看到尾,下一篇講路由的組織方式,route() 分組、把路由抽成 extension function,讓 Todo API 的路由有自己的檔案,module() 回到只負責組裝
同步刊登於 Blog
圖片來源:AI 產生